Repository navigation
feat(pages): fill the start page of an application with widgets - #3211
rubenvdlinde wants to merge 2 commits into
Conversation
The start page of an application is a grid page. A new application starts with a header showing its name and description and one full width table or view widget per table or view. A migration fills existing start pages without widgets the same way, in the order they listed their tables, and an import of data exported before pages held widgets does too. The page endpoint accepts widgets on the start page, which still cannot be deleted. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde <ruben@conduction.nl>
Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde <ruben@conduction.nl>
AndyScherzinger
left a comment
There was a problem hiding this comment.
Review focused on performance, accessibility, security, clean code and architecture. The inline comments only cover points that aren't in the existing threads on #3133, #3132 or #3131; where one follows from an earlier thread, it links to it. This PR has no UI of its own, so the accessibility points are about how #3216 renders the start pages this PR fills. Points marked as reproduced were checked on a dev instance (MariaDB) running this branch with its migration applied.
| } | ||
| } | ||
|
|
||
| foreach (GridWidgetTypes::startPageWidgets($context->getName(), $context->getDescription(), $nodeRelIds) as $widget) { |
There was a problem hiding this comment.
Only create(), the migration and the import fill the start page; everything that changes an application's tables and views afterwards still goes to page_content alone. update() (the Edit application dialog, and Import scheme, whose importScheme() ends in update()) adds a node through addNodeToContextAndStartpage() without a widget, and the order set in the dialog, like PUT /api/2/contexts/{contextId}/pages/{pageId} (updateContentOrder()), only rewrites page_content.order. From #3216 on the start page is rendered from its widgets, and page_content is left as the order of the dialog's own list (EditContext.vue). So a table added after creation never appears on Home, reordering in the dialog has no visible effect, and an application created empty and then filled through Import scheme shows its header and nothing else. With the "widget stays and asks for a new table" agreed in #3099, a table removed in the dialog, or deleted by its owner, also leaves a full-width "No table or view to show" box for every member, and adding it back places nothing. #3216's e2e tests already work around this, with addTableWidgetToStartPage() after saving the dialog in four tests.
Reproduced on the dev instance: an application created with two tables got a header and two data widgets. PUT /api/2/contexts/{id} adding a third table added its page_content row but no widget; a second PUT with the nodes in the order third, first reordered page_content, while the widgets stayed header, first table, and a data widget without source, with the third table nowhere.
The description accepts that tables added later aren't placed, but with Edit application and Import scheme unchanged that means the start page stops matching the application right after it is created, and the same URL now has a PATCH for its widgets and a PUT for an order nothing shows. Putting the rule back where every addition passes, addNodeRelToPage() appending a data widget below the lowest one of the start page (with insertPage() and importContext() using the same append), keeps both dialogs meaningful. The tables overview widget proposed in #3099 (comment), one widget listing the application's tables and views, would follow every change by construction.
|
|
||
| ]]></description> | ||
| <version>2.5.0-dev.0</version> | ||
| <version>2.5.0-dev.1</version> |
There was a problem hiding this comment.
#3135, on the same base, makes the same bump from 2.5.0-dev.0 to 2.5.0-dev.1 and ships its own step (Version2500Date20261103000000). The two hunks are identical, so they merge without a conflict and whichever PR lands second leaves the version as it is. Nextcloud only upgrades an app when the info.xml version differs from installed_version, so on every instance that already took the first one (dev instances tracking main) the second one's step stays pending until the next bump: if #3135 goes first, existing start pages stay unfilled there; in the other order, the menu endpoints run against a missing tables_contexts_menu_item. Same mechanism as #3131 (comment) with a different pair: whichever lands second needs to bump past what its base has by then, and the PRs stacked on it (#3212 is at dev.2) one higher each.
| public static function startPageWidgets(string $name, string $description, array $nodeRelIds): array { | ||
| $widgets = []; | ||
| $gridY = 0; | ||
| if (trim($name) !== '') { |
There was a problem hiding this comment.
Accessibility: the generated header repeats what the page around the start page already shows. In #3216, Context.vue renders the application's name as <h1> and its description above the page bar on every page, and GridPage.vue deliberately gives the start page no heading of its own ("The start page sits under the title of its application"). The header widget then renders the same name as <h2> and the description once more, as the Home screenshot in #3099 (comment) shows ("Intake portal" and "Requests from residents" twice). In heading navigation the name comes twice in a row, and screen reader users hear the description, when there is one, twice; through the migration this applies to every existing application. Because the header has no title, GridWidget.vue also labels its <section> with the type name, so every Home starts with a region landmark called "Header", which says nothing about its content.
#3216 keeps both on purpose, because the header is an ordinary widget people can change or remove, and offers to drop the name above the page bar instead. But the header is a copy that nothing keeps current: ContextService::update() changes the name and description without touching it, so after a rename (in Edit application, or by Import scheme, which passes the scheme's name to update()) the page shows the new name above the page bar and the old one in the header, for every member until the owner edits the widget. Reproduced on the dev instance: after PUT /api/2/contexts/{id} with a new name and description, the header still had the old heading and subheading. Dropping the name above the page bar would leave that stale copy as the start page's only title.
Filling the start page with its tables and views only keeps it the way it looks today; the data widgets already work that way, showing their table's live title instead of a copy. A header stays one "Add widget" away for owners who want one. If a generated header stays, it needs to follow the application, for example a header setting that shows the application's name instead of storing it.
| $widgets[] = [ | ||
| 'type' => self::TYPE_HEADER, | ||
| 'position' => ['gridX' => 0, 'gridY' => $gridY, 'gridWidth' => self::GRID_COLUMNS, 'gridHeight' => self::HEADER_HEIGHT], | ||
| 'settings' => ['heading' => $name, 'subheading' => $description, 'textAlign' => 'start', 'backgroundColor' => '', 'textColor' => ''], |
There was a problem hiding this comment.
Nothing checks these settings against the header schema: WidgetService::insert() and the migration store them as they are, while every later save of the page runs each widget through sanitizeSettings(). An application's description has no length limit (a TEXT column, unchecked by ContextController and by the dialogs' plain <input>), so a description that encodes to more than SETTINGS_MAX_BYTES gives a start page that can't be saved as it is: nothing on it can be moved, added or removed until someone shortens a subheading they never typed, and an account export and import drops the header ("Skipping an imported widget"). With the \uXXXX escaping from #3132 (comment) that takes about 10000 characters in a non-Latin script.
Reproduced on the dev instance: POST /api/2/contexts with a description of 10001 CJK characters (and another with 60000 ASCII characters) returned 200, and PATCH .../pages/{startPageId} sending the widgets back exactly as GET returned them answered 400 "The settings of a header widget take more than 60000 bytes."
The literal also repeats the schema's defaults for textAlign and the two colours, which already changed once in this stack (#3132 (comment)) and may again (#3132 (comment)); a setting that is renamed or added there without this line produces headers the API then refuses. If the header stays (see above), $this->sanitizeSettings(self::TYPE_HEADER, ['heading' => $name, 'subheading' => $description]), with the subheading cut to a length that always fits (the 2000 characters pages and widgets allow for descriptions, for example), keeps one source of defaults and rules. ContextService already has $this->gridWidgetTypes injected, so this can be an instance method; the migration shouldn't call it either way (see the comment there).
| * then one table or view widget per table or view, in the order the start page listed them. | ||
| * A start page that has widgets is left alone, so a second run changes nothing. | ||
| */ | ||
| class Version2500Date20261030000000 extends SimpleMigrationStep { |
There was a problem hiding this comment.
Two things about where this step sits in the stack.
It needs the widget tables that Version2500Date20261022000000 (#3131) creates, and steps run in name order. #3131 (comment) asks to give that step a current date before it lands; once it sorts after 20261030, this step has to move with it, or every upgrade from 2.4 runs it first and aborts on the missing tables_contexts_widget (fresh installs only apply the schema and don't notice). #3212's Version2500Date20261030010000 returns early when the widget table doesn't exist yet, so in the same situation it would leave out its columns without an error, on fresh installs as well.
And the fill happens at the upgrade to this PR, while only #3216 shows it; the description invites shipping this one earlier. Everything users add or reorder through Edit application in between only reaches page_content (see the comment on insertPage()), and applications created in between are filled once at creation, so when #3216 arrives, Home shows a snapshot from the earlier upgrade, which this step won't refresh (it skips pages that have widgets). Keeping the fill, create() and the import included, in the same release as #3216 avoids that.
| } | ||
|
|
||
| foreach (GridWidgetTypes::startPageWidgets($context->getName(), $context->getDescription(), $nodeRelIds) as $widget) { | ||
| $this->widgetService->insert($page->getId(), $widget['type'], null, $widget['position'], $widget['settings'], $widget['nodeRelId']); |
There was a problem hiding this comment.
Every new application now pays the per-widget pattern from #3132 (comment): WidgetService::insert() checks each source with a SELECT although the relation was inserted into this application a moment earlier in the same transaction, and setSource() deletes the source of every freshly inserted id, the header's included. On the dev instance the profiler shows 14 widget statements for POST /api/2/contexts with three tables (4 widget INSERTs, 4 source DELETEs, 3 source SELECTs, 3 source INSERTs); 7 of them do something.
The loop is copied into importContext() (line 746). Inserting the list startPageWidgets() returns with one WidgetService call, syncPage() with 'id' => null rows (one source check per page) or an insert without the check and the DELETE, used from both places, saves those statements and keeps the two fills from drifting apart.
| public function update(int $contextId, int $pageId, ?string $title, ?string $technicalName, ?string $icon, ?string $description, ?array $widgets, string $userId): array { | ||
| $context = $this->getManageableContext($contextId, $userId); | ||
| $pageArray = $this->getPageArray($context, $pageId); | ||
| $this->getPageArray($context, $pageId); |
There was a problem hiding this comment.
With its result discarded this reads like a leftover, but it is the only check that $pageId belongs to $contextId: pageMapper->findById() below loads any page, and RequirePermission and getManageableContext() only look at the application. The "update it" case of testAnApplicationOrPageOutsideTheUsersReachIsNotFound() would catch its removal, but nothing here says why it's needed. Keeping the result and checking the type there, in_array($pageArray['page_type'], [Page::TYPE_GRID, Page::TYPE_STARTPAGE], true), makes it read as the guard it is and keeps widgets off pages of other types, which older imports could create (#3133 (comment)).
| * Fields left out keep their value; an empty icon or description removes it. A given | ||
| * `widgets` list replaces the widgets of the grid page: listed widgets with an `id` | ||
| * are updated, others are added, and widgets not listed are deleted. | ||
| * `widgets` list replaces the widgets of the page, a grid page or the start page: |
There was a problem hiding this comment.
Now that the start page holds widgets, the descriptions around this one still say grid page: widgetTypes() is published as "[api v2] Get the widget types a grid page can hold" (openapi.json, openapi.ts), and the class docblocks of GridWidgetTypes ("The widget types a grid page can hold"), WidgetService ("Stores the widgets of grid pages") and Widget ("A widget on a grid page") say the same. The new migration's docblock calls the start page "a grid page", although its page_type stays startpage and PageService tells the two apart. "replaces the widgets of the page" here and "a page" in the others, then composer run openapi, keeps the API docs from telling clients that the start page takes no widgets.
| }; | ||
|
|
||
| $filled = $startPageWidgets($older, 4244); | ||
| $this->assertSame('header', $filled[0][0]); |
There was a problem hiding this comment.
These three assertions also pass for a header followed by the same data widget twice, or by a data widget without source, and the source the closure collects is never compared; with the one-table fixture, asserting the whole list, a header and one data widget with the imported relation as its source, says what the fill should produce. In ContextServiceTest::testANewApplicationStartsWithItsWidgets() the page content callback sets $content->id = 100 + $relId after all relations were inserted, so both contents of the two-node case get id 142 and insertPage() keeps only one of them in the context's pages, which the test doesn't look at; a counter of its own keeps the fake ids distinct.
| ]; | ||
| $gridY += self::HEADER_HEIGHT; | ||
| } | ||
| foreach (array_slice($nodeRelIds, 0, self::MAX_WIDGETS_PER_PAGE - count($widgets)) as $nodeRelId) { |
There was a problem hiding this comment.
Nothing says when this cuts: an application with more than 99 tables and views (nothing limits that) gets data widgets for the first 99 only, at creation, on upgrade and on import, while importContext() logs the same cut for exported widgets a few lines further down. With #3216 the rest simply aren't on Home, and the page is then at MAX_WIDGETS_PER_PAGE, so adding any widget answers 400 until the owner removes widgets they never placed. A log line here, and a message on the migration's unused $output, would at least tell someone.
Part of #3099, on top of #3133 (pages API, on current main as 2.5.0-dev.0). As agreed on #3099, the start page of an application is always a grid page. This is the backend for that: a new application starts with a header and its tables and views as widgets, and existing applications get the same once, on upgrade. It doesn't need the menu (#3135 to #3138), so it can go in while the menu model is reworked on #3100.
This is the first of the pull requests that build grid pages on top of #3133. They're stacked, each on the one before:
The menu frontend (F4 to F7: the menu editor, an application as its own app, the overview and the list of applications) waits for the new menu model and comes after it.
Size: 408 lines: 194 code, 210 tests and 4 generated OpenAPI (a changed endpoint description, in a separate commit).
What changes
ContextService::create): a full-width header with the application's name and description, then one full-width table or view widget per table or view, in the order the start page lists them. Each data widget's source is{nodeRelId}of one of the application's own tables and views, so nothing is added to the application. The header starts aligned tostart. Tables added later are not added as widgets.Version2500Date20261030000000, for every start page that has no widgets yet, inpage_contentorder. A start page that already has widgets is left alone, so running it again changes nothing. The app version goes from2.5.0-dev.0to2.5.0-dev.1, one dev step from its base, so instances already on dev.0 run it.widgetslist keeps exactly what it says, also when it's empty.PATCH /api/2/contexts/{contextId}/pages/{pageId}used to refuse widgets on the start page (400); it now accepts them. The start page still can't be deleted.page_contentis still written when tables are added, as before.The current frontend keeps showing the start page as its list of tables; #3216 shows the widgets.
How it was tested
On Nextcloud 34 with PostgreSQL:
Version2500Date20261030000000Test(database, 2 tests): an empty start page gets a header and its tables in page order, a broken content row is skipped and a second run changes nothing; a start page with widgets is left alone. Both fail without the migration.ContextServiceTest: creating an application fills its start page, with and without tables (one test, two cases); fails without the changeContextPagesRoundTripTest(database): a start page exported withoutwidgetsis filled on import, one with an empty list is notPageServiceTest: the start page holds widgets (was: refuses them); fails without the changecomposer run rector:check,php -l,composer cs:checkand psalm, all clean🖼️ Screenshots
No UI change; #3216 shows the filled start page.
🏁 Checklist
/backport to stableX.X🤖 AI (if applicable)